今天的主題是Redux,一開始我們先看它是如何運作的,Redux並不是只能在React應用中使用,而是可以在一般的應用中使用。第一個例子是一個簡單的JavaScript應用,它與最早之前的我們的TodoList應用的功能有點像。
這個程式最後的呈現結果,就像下面的動態圖片這樣,重點是在於下面有個Redux DevTools,它有時光旅行除錯的功能,可以倒帶重播你作過的任何資料上的變動:
註: 本文章同步放置於Github庫的這裡,所有的程式碼也在裡面。
這支簡單的應用是讓你學習Redux整個運作的過程用的,它只是個範例,在實際的應用中雖然會比較複雜,但基本的運作流程都是一樣的。本章的下面附了一些詳細的說明,建議你一定要看。Redux裡面有很多基本的概念與專有名詞,不學是很難看得到在說什麼東西。而且我覺得也是要再多思考一下,它的設計是集合了許多來自不同技術的合體。
首先我們要使用的是用於寫ES6練習用的那個樣版文件webpack-es6-startkit,而不是create-react-app這個,因為這個範例中並沒有要用到React。之前的文章中有說明如何安裝。
接著我們要多安裝redux套件進來,在專案目錄裡用命令列工具(終端機)輸入以下的指令:
npm install --save redux
另外你也需要安裝Chrome瀏覽器的外掛 - Redux DevTools,這可以讓你使用Redux中的時光旅行除錯功能。
我們在index.html中加了一個文字框itemtext
、按鈕itemadd
,以及一個準備要顯示項目列表的div區域itemlist
,程式碼如下:
index.html
<div>
<p>
<input type="text" id="itemtext" />
<button id="itemadd">Add</button>
</p>
</div>
<div id="itemlist">
</div>
程式碼檔案只有一個,就是index.js,它是在src/目錄下的,程式碼如下:
src/index.js
// @flow
import { createStore } from 'redux'
// @Reducer
//
// action payload = action.text
// 使用純粹函式的陣列unshift,不能有副作用
// state(狀態)一開始的值是空陣列`state=[]`
function addItem(state = [], action) {
switch (action.type) {
case 'ADD_ITEM':
return [action.text, ...state]
default:
return state
}
}
// @Store
//
// store = createStore(reducer)
// 使用redux dev tools
// 如果要正常使用是使用 const store = createStore(addItem)
const store = createStore(addItem,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())
// @Render
//
// render(渲染)是從目前store中取出state資料,然後輸出呈現在網頁上
function render() {
const items = store.getState().map(item => (
(item) ? `<li>${item}</li>` : ''
))
document.getElementById('itemlist').innerHTML = `<ul>${items.join('')}</ul>`
}
// 第一次要呼叫一次render,讓網頁呈現資料
render()
// 訂閱render到store,這會讓store中如果有新的state(狀態)時,會重新呼叫一次render()
store.subscribe(render)
// 監聽事件到 "itemadd" 按鈕,
// 點按事件會觸發store dispatch Action(store發送動作),例如
// `store.dispatch({ type: 'ADD_ITEM', textValue })
document.getElementById('itemadd')
.addEventListener('click', () => {
const itemText = document.getElementById('itemtext')
// flowtype檢查用的
if (itemText instanceof HTMLInputElement) {
// 呼叫store dispatch方法
store.dispatch({ type: 'ADD_ITEM', text: itemText.value })
// 清空文字輸入框中的字
itemText.value = ''
}
})
這個程式碼中我有排順序與加上中文註解,因為你要啟用Redux中的作用,是有順序的。我們一步步看下來:
第一步,是要從redux中匯入createStore
方法,這很簡單吧,如下面的程式碼:
import { createStore } from 'redux'
第二步,是要建立一個reducer(歸納函式),reducer要求一定要是純粹函式。那麼到底什麼是reducer的作用,就是傳入之前的state(狀態)與一個action(動作)物件,然後要回傳一個新的state(狀態)。
對我們這個簡單的應用來說,它只會有一種這種行為,就是在文字框輸入一些文字,按下按鈕後,把這串文字值加到state中。
所以它的state(狀態)是個陣列,每次一作動作,就加到這個陣列的最上面(索引值為0)一個,動作呢就是像下面這樣的一個物件描述:
{
type: 'ADD_ITEM',
text: text
}
reducer裡面通常會用動作的類型(action.type)用switch來區分要執行哪一段的程式碼,因為動作有可能會有很多不同的,像刪除項目、更新項目等等。程式碼如下:
// @Reducer
//
// action payload = action.text
// 使用純粹函式的陣列unshift,不能有副作用
// state(狀態)一開始的值是空陣列`state=[]`
function addItem(state = [], action) {
switch (action.type) {
case 'ADD_ITEM':
return [action.text, ...state]
default:
return state
}
}
上面的[action.text, ...state]
,它就是純粹函式寫法的陣列unshift方法,之前在TodoList中你應該也有見過。
第三步,是由寫好的reducer,建立store,其實這沒什麼好說的,就用匯入的createStore
方法把reducer傳入就行了。正常情況下是用const store = createStore(addItem)
,因為你要使用瀏覽器中的Redux DevTools,所以要改寫成下面這樣的程式碼:
// @Store
//
// store = createStore(reducer)
// 使用redux dev tools
// 如果要正常使用是使用 const store = createStore(addItem)
const store = createStore(addItem,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())
第四步,是寫一個render(渲染函式),這個函式是在如果狀態上有新的變化時,要作輸出呈現的動作。說穿了,這大概是仿照React應用的機制的作法,不過它這設計實際上與React差了十萬八千里,這個渲染函式裡最重要的是用store.getState()
方法取出目前store
裡面的狀態值,因為我們現在只有記一個state值,所以直接取出來就是剛剛在reducer裡記錄狀態值的那個陣列。剩下的就是一些格式的調整與輸出工作而已。程式碼如下:
// @Render
//
// render(渲染)是從目前store中取出state資料,然後輸出呈現在網頁上
function render() {
const items = store.getState().map(item => (
(item) ? `<li>${item}</li>` : ''
))
document.getElementById('itemlist').innerHTML = `<ul>${items.join('')}</ul>`
}
第五步,第一次呼叫一下render,讓目前的資料呈現在網頁上。因為我們一開始在state裡並沒有資料(空陣列),但也有可能原本是有一些資料的,這只是一個初始化資料的動作而已,也很簡單,程式碼如下:
// 第一次要呼叫一次render,讓網頁呈現資料
render()
第六步,訂閱render函式到store中,用的是store.subscribe
方法,這訂閱的動作會讓store中如果有新的state(狀態)時,就會重新呼叫一次render()。這也是一個很像是從React中抄來的設計吧?"當React中的state值改變(用setState),就會觸發重新渲染",不過在React中,setState你要自己作,沒有自動的機制。實際上這是從一個設計模式學來的作法,這種設計模式稱為pub-sub(發佈-訂閱)系統
,在Flux架構中就有這個設計,Redux中也有,不過它更簡化了整個流程。程式碼也只有一行:
// 訂閱render到store,這會讓store中如果有新的state(狀態)時,會重新呼叫一次render()
store.subscribe(render)
第七步,觸發事件的時候要呼叫store.dispatch(action)
。在我們的這個簡單的範例中,唯一會觸發事件就是按下那個加入文字的按鈕,按下後除了要抓取文字框的文字外,另外就是要呼叫store
要進行哪一個action
,這個動作用的是store.dispatch
方法,把action
值傳入,action的格式上面有看到過了。程式碼如下:
// 監聽事件到 "itemadd" 按鈕,
// 點按事件會觸發store dispatch Action(store發送動作),例如
// `store.dispatch({ type: 'ADD_ITEM', textValue })
document.getElementById('itemadd')
.addEventListener('click', () => {
const itemText = document.getElementById('itemtext')
// flowtype檢查用的
if (itemText instanceof HTMLInputElement) {
// 呼叫store dispatch方法
store.dispatch({ type: 'ADD_ITEM', text: itemText.value })
// 清空文字輸入框中的字
itemText.value = ''
}
})
以上就是這七個步驟,這個簡單的小程式,你要套用Redux這個規模化的架構,自然是有些殺雞用牛刀的感覺,但我們的目的是要學習它是怎麼運作的,你可以看到整個運作的核心就是store
,資料(state)在裡面,要與裡面的資料(state)更動,也是得用store
的方法才行。實際上到React中也是類似的運作方式,不過因為又加了一些額外的輔助套件,會比目前看到的還複雜些,基本的運作邏輯都差不多。
Redux的官網中用一句話來說明Redux是什麼:
Redux是針對JavaScript應用的可預測狀態容器
這句話說來簡短,其實是有幾個涵義的:
state
是集中在單一個物件樹狀結構下的單一store
,store
即是應用程式領域(app domain)的狀態集合。有些人可能會認為Redux一開始就是Facebook所建立的專案,其實並不是,它主要是由Dan Abramov所開始的,Dan Abramov進入Facebook的React小組工作是最近的事情。他還有建立另外還有其他的相關專案,像React Hot Loader、React DnD,可能比當時的Redux專案還更廣為人知,在Facebook發表Flux架構不久之後,許多Flux的週邊函式庫,不論是加強版、進化版、大改版…非常的多。Redux一開始的對外展示的大型活動,是在2015年的React-Europe,影片Live React: Hot Reloading with Time Travel。影片中就有簡單的說明,Redux用了"Flux + Elm"的概念。
Elm是一個程式語言,它也是一個年輕的語言,與原本的JavaScript的設計本質上有很多不同之處,但最後可以編譯為JavaScript執行。Redux中有很多設計是學習Elm而來的,例如:
當然除了Flux與Elm之外,還有其他的主要像RxJS中的概念與設計方式,Redux融合了各家的技術於一身,除了更理想的使用在Flux要解決的問題上之外,更延伸了一些不同的設計方式。
但是對初學者來說,它也不容易學習,網路上常常見到初學者報怨Redux實在有夠難學,這也並不是完全是Redux的問題,基本上來說Flux的架構原本就不是很容易理解,Redux還簡化了Flux的流程與開發方式。
如果你已經是剛說的幾種技術的熟練開發者,那當然沒什麼問題,Redux用起來很理想,也很容易上手。對初學者可不是那麼一回事,光Immutability、pure function、reducer要搞懂就要花點時間。另外有些來自Flux架構的store、dispatcher、action、action creator等技術名詞,雖然沒幾個,其實它們也是來自一些設計模式的東西,也需要花時間才能掌握。另一個要注意的是,Redux裡用的都是最新的ES6(ES2015)或甚至ES.next的語法,就算你之前有學過JavaScript或jQuery,這些語法如果沒學過也會很陌生。最後,Redux要套用到React中的規則還有很多,你可以看看它的官方手冊中,有很多規則都要遵守才行,要得心應手需要一些時間。
而且原本的Flux有Flux自己的問題,Redux也有自己的問題: Redux的Immutability設計除了時光旅行除錯,當然也有很多好處,函式保持純粹(pure),容易除錯是一個。但當你要使用不純粹的函式時,例如要用ajax往網路上的伺服器抓取資料時該怎麼作?這就是Redux自己本身的問題了。另一個很明顯的問題,是Redux只用單一個物件大樹結構的store,記錄大大小小的state(狀態)變化,如果在會持續更新狀態的情況下,這個樹會長得滿可怕的,時光旅行當然是很好的功能,但也有可能是變成一個效能上的問題,這效能的最佳化問題又該怎麼作?當然,我說的只是兩個很明顯的問題,而且你也猜得到Redux中早就已經有對應的解決方案,畢竟用得人多,你會遇到的問題大概都有人遇過,也都能找得到解決方式,這是開放原始碼生態圈的紅利。
我只能說如果你真的要學會React,並用它來開發一個稍有規模的應用,學習Redux說是一條必經之路,當然也有其他的Flux類函式庫可以選擇,但目前來說Redux的使用社群是最龐大也是最活躍的,而且也不見得其他的函式庫就會更容易學習與使用。
你心中應該要先有一個概念,這種架構或框架,本身就是為React應用規模化而設計的,這是大應用的架構,所以有很多強硬規則是必然的。雖然在這裡能提供一些較為簡易的入門知識,但真正要套用到實際的應用上,仍然有很多的基礎知識是要再深入與學習的。如果,你會覺得學起來很痛苦或常看不懂範例,大部份是因為基礎不足夠造成的,學習本來就要由淺入深循序漸進,是急不得的。
這三大基本原則是寫給React或Flux不熟的人看的,因為有可能怕你不理解這些函式庫中的一些限制或設計,所以會先寫出來提醒一下,當然它裡面就說明了Redux的設計原理基礎是如何,所以你要用之前要先理解一下。
你的整個應用中的state(狀態)會儲存在單一個store(儲存)之中的一個物件樹狀結構裡。
Redux中只有用單一個物件大樹結構來的儲存狀態,稱之為store
,但它並不是只有單純的儲存資料而已。store
就是應用程式領域的state
(狀態),已經有像MVC中的Model設計的概念,這名稱是由Flux來的,Flux架構原本是多個store
的結構,Redux簡化它為單一個。
我們之前的React範例中也到最後,也是只有一個最上層的state
來管控應用程式中的狀態,但我們能作到的只有單純的保存資料而已,更動這些資料的所有方法,雖然都是寫在上層元件之中,但是它們並沒有一個很標準的寫法。
不過Redux中的store
儲存資料的結構與React中的state
不同的地方,就是它會保存所有的更動,任何只要對狀態進行更動,都會保存進去,這是它的特色。
因為只有一個store
,但如果store
裡要儲放多個不同的狀態物件,以及每次的更動資料,自然就會變成了物件樹狀結構(object tree)。
唯一能更動狀態的是發送(emit)一個
action
(動作),action
是一個描述發生了什麼事的物件。
這與原先的React中的state
與setState
的概念有點像,Redux的意思是你不能直接更動store
裡面記錄的狀態值,只能"間接"地透過發送action
物件來叫store
更動狀態。
發送(emit)一個action,用的是store.dispatch(action)
語法,下面這個範例就是一個要更動狀態的程式碼:
store.dispatch({
type: 'COMPLETE_TODO',
index: 1
})
中間的那個物件,就叫作action
(動作),它是一個單純用於描述發生了什麼事的物件:
{
type: 'COMPLETE_TODO',
index: 1
}
還記得我們在React中的state
與setState
方法的設計嗎?state
也是不能直接更動的,一定要透過setState
方法才能更動它。那這是為什麼呢?因為setState
不光只是更動state
值,它還要作重新渲染的動作。
也就是說假設Redux的設計中store
是會對應到React中的state
設計的話,它們之間有一些聯結,因為React中的state
值不能直接更動,所以Redux中的store
也不能直接更動。
store
實際上是一種state
擴充的結構,原先的state
只能記錄單純的物件資料結構,store
不止如此,它已經接近MVC的Model(模型)的設計。對於Flux的架構解說,在我的部落格裡有以下的對store
的說明:
store(儲存)的角色並非只是元件中的state(狀態)而已,它也不會只有單純的記錄資料,可能在現今的每種不同的Flux延伸的函式庫,對於store的定義與設計都有所不同。在Flux的架構中的store中,它包含了對資料更動的函式,Flux稱這些函式為Store Queries(儲存查詢),也把它的角色定位為類似傳統MVC的Model(模型),但與傳統的Model(模型)明顯不同的是,store只能透過Action(動作)以"間接"的方式來自我更新(self-updates through Actions)。
要指示狀態樹要如何依actions(動作)來改變,你需要寫純粹的歸納函式(reducers)。
Redux中的reducer的原型會長得像下面這樣,你可以把它當作就是 之前的狀態 + 動作 = 新的狀態
的公式:
(previousState, action) => newState
註: 你可以參考Redux中Reducers這一章的內容,裡面有實例。
不過,Redux中的reducer要求的是一定是pure function(純粹函式),也就是不能有副作用(side effect)的函式。因此由reducer所產生的新狀態,並不是直接修改之前的狀態而來,而是一個複製之前狀態,然後加上動作改變的部份,產生的一個新的物件,它這樣設計是有原因的。
如果你已經看過Flux的設計與運作原理,那麼在Redux中你需要學習的第一個基礎是reducer與pure function。Flux架構的一些簡單的說明,我在部落格中有一篇Flux - 為React打造的單向資料流架構,裡面有說一些概念的部份,你可以先看。
Pure function(純粹函式)的概念在之前的章節已經說明過了,就不再重覆說明,你也要先了解。
reducer(歸納器)這種函式的名稱,是由陣列的一個迭代方法reduce(歸納)而來,你可以參考MDN中的相關說明,以下的內容與範例出自從ES6開始的JavaScript學習生活這本電子書中:
reduce(歸納)這個方法是一種應用於特殊情況的迭代方法,它可以藉由一個回調(callback)函式,來作前後值兩相運算,然後不斷縮減陣列中的成員數量,最終回傳一個值。reduce(歸納)並不會更動作為傳入的陣列(呼叫reduce的陣列),所以它也沒有副作用。
const aArray = [0, 1, 2, 3, 4]
const total = aArray.reduce(function(pValue, value, index, array){
return pValue + value
})
console.log(aArray) // [0, 1, 2, 3, 4]
console.log(total) // 10
按照這個邏輯,reduce(歸納)具有分散運算的特點,可以用於下面幾個應用之中:
store
也是由Flux架構來的產物,在Flux中的store
的意義是用來對應元件中的state
字詞,大概有幾個重點,Redux中的設計也是有類似的概念:
store
代表的是應用程式領域的持續性儲存資料state
的容器,經常會對應到最上層元件的state
,或是各個元件state
的集合體Model
,不過只能透過動作"間接"作自我更新(self-updates through Actions)與Flux的設計最大不同,Redux只有單一個store
,它是一個樹狀結構的物件。而且,在Flux中的AppDispatcher工作,會由store中的方法來取代,大幅度地簡化原本複雜的流程。store
中有四個主要方法:
dispatch(action)
發送一個Action(動作)。這也是唯一可以觸發狀態改變的方式。常用於元件要用來觸發事件時。getState()
回傳目前store的狀態樹。常用於最上層元件取得目前狀態值。subscribe(listener)
加入一個更動的監聽者(函式)。它會在當有一個Action(動作)被發送時呼叫,或是當狀態樹被更動時呼叫。replaceReducer(nextReducer)
更換目前store用來計算state使用的reducer。屬於進階的API。註: 不用redux-react時,需要用
subscribe(listener)
來作更新時重新渲染。
所以在React與Redux配合時,只會用到前面兩個dispatch(action)
與getState()
而已,不需要用到subscribe
,也不適合使用subscribe
,這一點要特別注意。
最後,store(儲存)一開始的建立是用createStore(reducer)
來建立實體,所以你要先寫出一個reducer,才有辦法建立store。
這兩個是Flux架構中的參與成員,redux中有說明Action的定義:
Actions are payloads of information that send data from your application to your store.
中譯: Actions(動作)是從你的應用送往store(儲存)的資訊負載
你可能會一直在Action(動作)這裡看到payload
這個字詞,它是負載
或有效資料
的意思,這個字詞的意思解說你可以看一下,不難理解:
Payload用在電腦科學的意思,是指在資料傳輸時的"有效資料"部份,也就是不包含傳輸時的頭部資訊或metadata等等用於傳輸其他資料。它的英文原本是指是飛彈或火箭的搭載的真正有效的負載部份,例如炸藥或核子彈頭,另外的不屬於payload的部份當然就是火箭傳送時用的燃料或控制零件。
這個Action是有一個固定格式的,叫作FSA, Flux Standard Action(Flux標準動作),格式的範例會像下面這樣,是個JavaScript的物件字面定義:
{
type: 'ADD_TODO',
payload: {
text: 'Do something.'
}
}
這樣一個用於描述動作的單純物件字面定義,就稱為Action(動作)。
為什麼要先寫出明確的Actions(動作),也就是把所有的元件會用到的Actions(動作),全部集中寫到一個檔案中?這也是個硬規則,就像你如果參加奧運的體操比賽,每種項目都有規定的動作,在一定的時間內只能作這些動作,按照表定執行。主要還是因為Redux並不知道你的應用程式裡會作什麼動作,需要有一個明確說明有哪些動作的地方,在運作時以這個對照表為基準。
當然,聰明如你一定知道,Actions(動作)必需要有type(類型),而且在同一個應用中的type(類型)名稱是不能重覆的,它的概念有點類似於資料表中的主鍵屬性。
那麼Action Creator(動作生成器)又是什麼?
在程式語言的函式庫中,如果是個英文的名詞,通常都是代表某種物件或資料格式,例如Action(動作)就是個單純的物件。如果叫什麼xxxxter或xxxxtor的,中文翻譯是"器"、"者",通常就是個函式或方法,像上面的reducer和這裡的Action Creator,都是一種函式。
Action creator的設計也是由Flux架構來的產物,它是一種輔助用的函式,用來建立Action的。因為設計的不同,在Redux中的Action creator比在Flux更簡單,它通常只用來回傳Action物件而已,當然它本身是個函式,在回傳前是可以再針對回傳的動作資料先進行運算或整理的,例如像下面這樣的函式:
export function addTodo(text) {
return { type: ADD_TODO, text }
}
這個addTodo
函式,有一個傳入參數,這個傳入參數就會用於組成Action物件中的payload
(有效資料)。
如果一個Action物件簡單到連payload(有效資料)都沒有,通常會是個固定payload(有效資料)的動作,例如每動作一次+1或-1,或是每動作一次在true或false值切換,那麼在Redux中允許連Action或Action Creator都可以不用寫了。但是這種情況大概只有在很小的應用,或是學習階段的範例才會這樣,如果應用還是有一定程度的複雜度,一定都是要寫出來的。
註: Action Creator在Redux中並沒有要求一定要是個純粹函式,只是不建議在裡面直接執行有副作用的函式,之後的章節會有說明。請參考這篇在stackoverflow的Reduce作者的回答。